Skip to content

Conversation

struckoff
Copy link
Contributor

@struckoff struckoff commented Aug 11, 2025

Description

Fixes #<issue_number> (if applicable)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • MCP spec compatibility implementation
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring (no functional changes)
  • Performance improvement
  • Tests only (no functional changes)
  • Other (please describe):

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the documentation accordingly

MCP Spec Compliance

  • This PR implements a feature defined in the MCP specification
  • Link to relevant spec section: Link text
  • Implementation follows the specification exactly

Additional Information

This fix ensures compliance with the MCP ping/pong specification:

  • Ping request: {"jsonrpc": "2.0", "id": "123", "method": "ping"}
  • Pong response: {"jsonrpc": "2.0", "id": "123", "result": {}}

Previously, when WithHeartbeatInterval was enabled in the HTTP Streamable MCP server, Cursor AI and Claude Desktop got a 400 error - "Missing session ID for sampling response" - because the Pong response was incorrectly interpreted as a SamplingResponse.

Without heartbeat support, Cursor disconnects from MCP after a 5-minute timeout.

Summary by CodeRabbit

  • Bug Fixes

    • Ignore empty ping/pong JSON-RPC responses in the streaming HTTP endpoint so they are not misclassified as sampling responses, reducing spurious errors and improving stability when clients send empty result, omitted, or empty-error payloads.
  • Tests

    • Added tests verifying pong/ping cases return HTTP 200 and do not trigger sampling-related logic.

Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Walkthrough

Skip empty JSON-RPC ping/pong responses in StreamableHTTPServer POST handling by detecting messages with an ID and empty Result/Error via a new isJSONEmpty helper; adjust isSamplingResponse formatting and add tests validating pong handling.

Changes

Cohort / File(s) Summary
Server: Ping/Pong handling logic
server/streamable_http.go
Add isJSONEmpty(json.RawMessage) bool helper and isPingResponse detection (no Method, ID present, Result/Error empty). Early-return on ping responses in POST path; minor formatting/refactor of isSamplingResponse. No exported API changes.
Server tests: Pong handling
server/streamable_http_test.go
Add TestStreamableHTTP_PongResponseHandling with subtests verifying empty-result, omitted/null-result, and empty-error responses are not treated as sampling responses; add postJSON test helper.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • pottekkat
  • robert-jackson-glean

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly and accurately describes the primary change — detecting Pong responses from MCP clients and skipping session ID validation — and aligns with the PR objectives and code/test changes included in the diff.
Description Check ✅ Passed The PR description largely follows the repository template and includes Type of Change, Checklist, MCP spec compliance, and useful Additional Information explaining the ping/pong issue and tests, but the top "Description" section still contains the placeholder "Fixes #<issue_number>" and does not include a one-line concise summary directly under the Description header.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
server/streamable_http.go (1)

249-251: LGTM with minor edge case consideration.

The updated sampling response detection logic correctly differentiates from ping responses. However, consider handling the edge case where both Result and Error are nil - such messages would bypass both ping and sampling detection.

Consider adding explicit validation:

 isSamplingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
-	(jsonMessage.Result != nil || jsonMessage.Error != nil)
+	(jsonMessage.Result != nil || jsonMessage.Error != nil) &&
+	!isPingResponse
server/streamable_http_test.go (1)

897-990: Improve test coverage and assertions.

The test structure is good and covers the main scenarios, but could be enhanced:

  1. Missing positive assertions: Tests only verify that sampling errors don't occur, but don't confirm that ping responses are actually handled correctly.

  2. Unrealistic scenario: The third test case with empty error may not represent a realistic ping/pong scenario according to the MCP spec.

Consider these improvements:

+	// First verify a real sampling response still triggers the error
+	t.Run("Real sampling response should still require session ID", func(t *testing.T) {
+		samplingResponse := map[string]any{
+			"jsonrpc": "2.0",
+			"id":      126,
+			"result":  map[string]any{"content": "test"},
+		}
+		
+		resp, err := postJSON(server.URL, samplingResponse)
+		if err != nil {
+			t.Fatalf("Failed to send sampling response: %v", err)
+		}
+		defer resp.Body.Close()
+		
+		if resp.StatusCode == http.StatusOK {
+			t.Error("Expected sampling response to fail without session ID")
+		}
+	})

Also consider verifying response content more thoroughly:

 		if resp.StatusCode != http.StatusOK {
 			t.Errorf("Expected status 200 for pong response, got %d. Body: %s", resp.StatusCode, bodyStr)
 		}
+		
+		// Verify response body is empty (as expected for ping responses)
+		if len(bodyBytes) > 0 {
+			t.Errorf("Expected empty response body for ping, got: %s", bodyStr)
+		}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8a88d01 and 247a550.

📒 Files selected for processing (2)
  • server/streamable_http.go (1 hunks)
  • server/streamable_http_test.go (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
server/streamable_http.go (1)
mcp/types.go (1)
  • Result (245-249)

@struckoff struckoff force-pushed the hotfix/streamable-pong-response branch from 2d6f1a2 to b8977cd Compare August 12, 2025 12:20
@ezynda3 ezynda3 added the type: bug Something isn't working as expected label Sep 19, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
server/streamable_http.go (1)

267-274: Explicitly acknowledge pong and make detection resilient to meta-only payloads

  • Early return risks middleware/proxies misinterpreting the response. Send 200 OK explicitly.
  • Treat result/error objects containing only "_meta" as empty to match tolerant clients per prior feedback.

Apply:

-	// detect empty ping response, skip session ID validation
-	isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
-		(isJSONEmpty(jsonMessage.Result) && isJSONEmpty(jsonMessage.Error))
-
-	if isPingResponse {
-		return
-	}
+	// detect pong response (no method, has id, and empty/omitted result or error)
+	isPingResponse := jsonMessage.Method == "" && jsonMessage.ID != nil &&
+		(isJSONEmpty(jsonMessage.Result) || isJSONEmpty(jsonMessage.Error))
+
+	if isPingResponse {
+		w.WriteHeader(http.StatusOK)
+		return
+	}

And update isJSONEmpty as suggested below to consider {"_meta": ...} empty as well. Also, please add a small debug/info log when a pong is detected for observability. Would you like me to add a follow-up commit with tests for the {"_meta":...} case?

🧹 Nitpick comments (2)
server/streamable_http.go (1)

951-997: Replace byte-scanning with parse-based emptiness; drop unnecessary imports

  • Current byte/rune scanning is brittle and doesn’t recognize {"_meta":...} as empty.
  • JSON whitespace is ASCII; using unicode.IsSpace on bytes is unnecessary.
  • Simplify by unmarshalling objects and checking keys.

Apply:

-// isJSONEmpty reports whether the provided JSON value is "empty":
-//   - null
-//   - empty object: {}
-//   - empty array: []
-// It also treats nil/whitespace-only input as empty.
-// It does NOT treat 0, false, "" or non-empty composites as empty.
-func isJSONEmpty(data json.RawMessage) bool {
-	if len(data) == 0 {
-		return true
-	}
-
-	trimmed := bytes.TrimSpace(data)
-	if len(trimmed) == 0 {
-		return true
-	}
-
-	switch trimmed[0] {
-	case '{':
-		if len(trimmed) == 2 && trimmed[1] == '}' {
-			return true
-		}
-		for i := 1; i < len(trimmed); i++ {
-			if !unicode.IsSpace(rune(trimmed[i])) {
-				return trimmed[i] == '}'
-			}
-		}
-	case '[':
-		if len(trimmed) == 2 && trimmed[1] == ']' {
-			return true
-		}
-		for i := 1; i < len(trimmed); i++ {
-			if !unicode.IsSpace(rune(trimmed[i])) {
-				return trimmed[i] == ']'
-			}
-		}
-
-	case '"': // treat "" as not empty
-		return false
-
-	case 'n': // null
-		return len(trimmed) == 4 &&
-			trimmed[1] == 'u' &&
-			trimmed[2] == 'l' &&
-			trimmed[3] == 'l'
-	}
-	return false
-}
+// isJSONEmpty reports whether the provided JSON value is "empty":
+//   - null
+//   - empty object: {}
+//   - object with only "_meta" key
+//   - omitted (nil)
+// NOTE: We intentionally do NOT treat []/""/0/false as empty.
+func isJSONEmpty(data json.RawMessage) bool {
+	if len(data) == 0 {
+		return true
+	}
+	// null
+	if string(data) == "null" {
+		return true
+	}
+	// object {}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err == nil {
+		if len(obj) == 0 {
+			return true
+		}
+		if len(obj) == 1 {
+			_, onlyMeta := obj["_meta"]
+			return onlyMeta
+		}
+	}
+	return false
+}

And clean up imports:

-import (
-	"bytes"
+import (
 	"context"
 	"encoding/json"
 	"fmt"
 	"io"
 	"mime"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
-	"unicode"
server/streamable_http_test.go (1)

897-991: Good coverage; add a meta-only pong case

Add a subtest for {"result":{"_meta":{...}}} to ensure tolerant handling, and to lock in the helper behavior.

Suggested addition:

 	t.Run("Pong response with empty result should not be treated as sampling response", func(t *testing.T) {
 		// ...
 	})
 
+	t.Run("Pong response with only _meta in result should not be treated as sampling response", func(t *testing.T) {
+		pongResponse := map[string]any{
+			"jsonrpc": "2.0",
+			"id":      126,
+			"result": map[string]any{
+				"_meta": map[string]any{"client": "test"},
+			},
+		}
+		resp, err := postJSON(server.URL, pongResponse)
+		if err != nil {
+			t.Fatalf("Failed to send pong response: %v", err)
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			bodyBytes, _ := io.ReadAll(resp.Body)
+			t.Fatalf("Expected status 200, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
+		}
+	})
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d6f1a2 and 66c8650.

📒 Files selected for processing (2)
  • server/streamable_http.go (4 hunks)
  • server/streamable_http_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
server/streamable_http_test.go (2)
server/server.go (1)
  • NewMCPServer (334-362)
server/streamable_http.go (1)
  • NewTestStreamableHTTPServer (945-949)

@ezynda3 ezynda3 merged commit 3288753 into mark3labs:main Sep 19, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: bug Something isn't working as expected
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants